import 'dart:convert';

import 'package:dbio_utils/gff/fasta_parser.dart';
import 'package:dbio_utils/gff/gff.dart';
import 'package:dbio_utils/gff/util.dart' as GFF3;

class GffParser {
  Function featureCallback;
  Function commentCallback;
  Function errorCallback;
  Function directiveCallback;
  Function sequenceCallback;
  Function endCallback;

  // number of lines to buffer
  int bufferSize;

  // features that we have to keep on hand for now because they
  // might be referenced by something else
  List<dynamic> _underConstructionTopLevel = [];

  // index of the above by ID
  Map _underConstructionById = {};

  Map _completedReferences = {};

  // features that reference something we have not seen yet
  // structured as:
  // {  'some_id' : {
  //     'Parent' : [ orphans that have a Parent attr referencing it ],
  //     'Derives_from' : [ orphans that have a Derives_from attr referencing it ],
  //    }
  // }
  Map _underConstructionOrphans = {};

  // if this is true, the parser ignores the
  // rest of the lines in the file.  currently
  // set when the file switches over to FASTA
  bool eof;

  int lineNumber;

  FASTAParser fastaParser;

  final Map<String, String> containerAttributes = {
    'Parent': 'child_features',
    'Derives_from': 'derived_features',
  };

  GffParser({
    this.featureCallback,
    this.commentCallback,
    this.errorCallback,
    this.directiveCallback,
    this.sequenceCallback,
    this.endCallback,
    this.bufferSize = 1000,
    this.eof = false,
    this.lineNumber = 0,
  });

  void addLine(String line) {
    // if we have transitioned to a fasta section, just delegate to that parser
    if (this.fastaParser != null) {
      this.fastaParser.addLine(line);
      return;
    }
    if (this.eof) {
      // otherwise, if we are done, ignore this line
      return;
    }

    this.lineNumber += 1;

    if (RegExp('^\s*[^#\s>]').hasMatch(line)) {
      // feature line, most common case
      this._bufferLine(line);
      return;
    }

    var match = RegExp('^\s*(#+)(.*)').firstMatch(line);
    if (match != null) {
      // directive or comment
      var hashSigns = match.group(1);
      String contents = match.group(2);

      if (hashSigns.length == 3) {
        // sync directive, all forward-references are resolved.
        this._emitAllUnderConstructionFeatures();
      } else if (hashSigns.length == 2) {
        var directive = GFF3.parseDirective(line);
        if (directive.directive == 'FASTA') {
          this._emitAllUnderConstructionFeatures();
          this.eof = true;
          this.fastaParser = new FASTAParser(this.sequenceCallback);
        } else {
          this._emitItem(directive);
        }
      } else {
        contents = contents.replaceFirst(RegExp('\s*'), '');
        this._emitItem(CommentFeature({'comment': contents}));
      }
    } else if (RegExp('^\s*\$').hasMatch(line)) {
      // blank line, do nothing
    } else if (RegExp('^\s*>').hasMatch(line)) {
      // implicit beginning of a FASTA section
      this._emitAllUnderConstructionFeatures();
      this.eof = true;
      this.fastaParser = new FASTAParser(this.sequenceCallback);
      this.fastaParser.addLine(line);
    } else {
      // it's a parse error
      var errLine = line.replaceAll(RegExp('\r?\n?\$'), '');
      throw Exception('GFF3 parse error.  Cannot parse $errLine.');
    }
  }

  _emitItem(i) {
    if (i is GffFeature) {
      this.featureCallback?.call(i);
    } else if (i is DirectiveFeature) {
      this.directiveCallback?.call(i);
    } else if (i is CommentFeature) {
      this.commentCallback?.call(i);
    } else if (i is List) {
      i.forEach(_emitItem);
    }
  }

  finish() {
    this._emitAllUnderConstructionFeatures();
    if (this.fastaParser != null) this.fastaParser.finish();
    this.endCallback?.call();
  }

  _enforceBufferSizeLimit([additionalItemCount = 0]) {
    _unbufferItem(dynamic item) {
      if (item != null &&
          item is List &&
          item[0] != null &&
          item[0].attributes != null && //
          item[0].attributes['ID'] != null &&
          item[0].attributes['ID'][0] != null) {
        List ids = item[0].attributes['ID'];
        ids.forEach((id) {
//  delete this._underConstructionById[id];
//  delete this._completedReferences[id];
          _underConstructionById.remove(id);
          _completedReferences.remove(id);
        });
        item.forEach((i) {
          if (i['child_features'] != null) i['child_features'].forEach((c) => _unbufferItem(c));
          if (i['derived_features'] != null) i['derived_features'].forEach((d) => _unbufferItem(d));
        });
      }
    }

    while (this._underConstructionTopLevel.length + additionalItemCount > this.bufferSize) {
      BaseFeature item = this._underConstructionTopLevel.removeAt(0);
      this._emitItem(item);
      _unbufferItem(item);
    }
  }

  /// return all under-construction features, called when we know
  /// there will be no additional data to attach to them
  /// @private
  _emitAllUnderConstructionFeatures() {
    this._underConstructionTopLevel.forEach(this._emitItem);

    this._underConstructionTopLevel = [];
    this._underConstructionById = {};
    this._completedReferences = {};

    // if we have any orphans hanging around still, this is a
    // problem. die with a parse error
    if (_underConstructionOrphans.values.where((entry) => entry.length > 0).length > 0) {
      print(_underConstructionOrphans);
      _underConstructionOrphans.clear();
      // throw Exception(
      //   'some features reference other features that do not exist in the file (or in the same ### scope). ${this._underConstructionOrphans}',
      // );
    }
  }

  // do the right thing with a newly-parsed feature line
  _bufferLine(String line) {
    var featureLine = GFF3.parseFeature(line);
    featureLine['child_features'] = [];
    featureLine['derived_features'] = [];
    // featureLine._lineNumber = this.lineNumber //< debugging aid

    // NOTE: a feature is an arrayref of one or more feature lines.
    List ids = featureLine.attributes['ID'] ?? [];
    List parents = featureLine.attributes['Parent'] ?? [];
    List derives = featureLine.attributes['Derives_from'] ?? [];

    if (ids.length == 0 && parents.length == 0 && derives.length == 0) {
      // if it has no IDs and does not refer to anything, we can just
      // output it
      this._emitItem(featureLine);
      return;
    }

    List<GffFeature> feature;
    ids.forEach((id) {
      List existing = this._underConstructionById[id];
      if (existing != null && existing.length > 0) {
        // another location of the same feature
        if (existing.last.type != featureLine.type) {
          this._parseError(
            'multi-line feature "$id" has inconsistent types: "${featureLine.type}", "${existing[existing.length - 1].type}"',
          );
        }
        existing.add(featureLine);
        feature = existing;
      } else {
        // haven't seen it yet, so buffer it so we can attach
        // child features to it
        feature = [featureLine];

        this._enforceBufferSizeLimit(1);
        if (parents.length == 0 && derives.length == 0) {
          this._underConstructionTopLevel.addAll(feature);
        }
        this._underConstructionById[id] = feature;

        // see if we have anything buffered that refers to it
        this._resolveReferencesTo(feature, id);
      }
    });

    // try to resolve all its references
    this._resolveReferencesFrom(
      feature ?? [featureLine],
      {'Parent': parents, 'Derives_from': derives},
      ids,
    );
  }

  _resolveReferencesTo(List<BaseFeature> features, id) {
    Map references = this._underConstructionOrphans[id];
    //   references is of the form
    //   {
    //     'Parent' : [ orphans that have a Parent attr referencing this feature ],
    //     'Derives_from' : [ orphans that have a Derives_from attr referencing this feature ],
    //    }
    if (references == null) return;
    references.keys.forEach((attrName) {
      var pname = containerAttributes[attrName] ?? attrName.toLowerCase();
      features.forEach((loc) {
        loc[pname].addAll(references[attrName]);
        references.remove(attrName);
//      delete references[attrName];
      });
    });
  }

  _parseError(message) {
    this.eof = true;
    this.errorCallback('${this.lineNumber}: ${message}');
  }

  _resolveReferencesFrom(features, Map references, List ids) {
    // this is all a bit more awkward in javascript than it was in perl
    bool postSet(obj, slot1, slot2) {
      var subObj = obj[slot1];
      if (subObj == null) {
        subObj = {};
        obj[slot1] = subObj;
      }
      var returnVal = subObj[slot2] ?? false;
      subObj[slot2] = true;
      return returnVal;
    }

    references.forEach((key, toIds) {
      var pname;
      toIds.forEach((toId) {
        var otherFeature = this._underConstructionById[toId];
        if (otherFeature != null) {
          if (pname == null) pname = containerAttributes[key] ?? key.toLowerCase();

          if (ids.where((id) => postSet(this._completedReferences, id, '$key,$toId')).length <= 0) {
            otherFeature.forEach((location) {
              location[pname].addAll(features);
            });
          }
        } else {
          if (this._underConstructionOrphans[toId] == null) this._underConstructionOrphans[toId] = {};
          if (this._underConstructionOrphans[toId][key] == null) this._underConstructionOrphans[toId][key] = [];
          this._underConstructionOrphans[toId][key].addAll(features);
        }
      });
    });
  }
}
